TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import Link from "next/link";2import type { Metadata } from "next";3import { notFound } from "next/navigation";4import type { ReactNode } from "react";5import { IMPORTANCE_WEIGHTS } from "@websensor/core/client";6import { DiffViewer } from "@/components/diff-viewer";7import { BookmarkButton } from "@/components/event-drawer";8import { EventRow } from "@/components/event-row";9import { FieldChangeInline, FieldChanges } from "@/components/field-changes";10import { Badge, Chip, Empty, EvidenceTag, ExtLink, Flag, Gauge, HealthPill, PageHeader, Panel, Score, StateBadge, Table, Td, TypeChip } from "@/components/ui";11import { ShareButton, WatchButton } from "@/components/watch-button";12import { api, SITE_URL, type SnapshotRow } from "@/lib/api";13import { CLASS_LABELS, fmtBytes, fmtMs, fmtOffset, fmtPct, fmtScore, relTime, shortHash, typeLabel, utcDateTime } from "@/lib/format";1415export const dynamic = "force-dynamic";1617export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {18 const { slug } = await params;19 const d = await api.event(slug);20 if (!d) return { title: "Event not found" };21 const e = d.event;22 const desc = e.summary.slice(0, 200);23 return {24 title: e.title,25 description: desc,26 alternates: { canonical: `/event/${e.slug}` },27 openGraph: { type: "article", title: e.title, description: desc, url: `${SITE_URL}/event/${e.slug}`, publishedTime: e.detected_at, tags: [e.event_type, ...e.categories] },28 twitter: { card: "summary_large_image", title: e.title, description: desc },29 };30}3132function Row({ label, value, tone = "" }: { label: string; value: ReactNode; tone?: string }) {33 return (34 <>35 <dt className="text-fg-subtle">{label}</dt>36 <dd className={`text-right font-mono tabular ${tone}`}>{value}</dd>37 </>38 );39}4041function Cell({ v, l }: { v: number | string; l: string }) {42 return (43 <div className="rounded-md border border-line bg-panel-2 px-2 py-1.5 text-center">44 <div className="font-mono text-base font-semibold tabular">{v}</div>45 <div className="label !text-[9.5px]">{l}</div>46 </div>47 );48}4950export default async function EventPage({ params }: { params: Promise<{ slug: string }> }) {51 const { slug } = await params;52 const d = await api.event(slug);53 if (!d) notFound();54 const e = d.event;55 const change = e.change_id ? await api.change(e.change_id) : null;56 const interp = (e.interpretation ?? {}) as Record<string, unknown>;57 const observed = typeof interp.observed === "string" ? interp.observed : null;58 const inferred = typeof interp.inferred === "string" && interp.inferred.trim() ? interp.inferred : null;59 const who = typeof interp.who_it_affects === "string" ? interp.who_it_affects : null;60 const model = typeof interp.model === "string" ? interp.model : "heuristics-v1";61 const oldSnap = d.snapshots.find((s) => s.id === e.old_snapshot_id);62 const newSnap = d.snapshots.find((s) => s.id === e.new_snapshot_id);63 const subject = e.entities.find((x) => x.role === "subject") ?? e.entities[0];64 const signal = e.signal_score ?? e.importance;65 const cluster = e.cluster;66 const clusterHref = cluster ? `/cluster/${cluster.slug ?? cluster.id}` : d.cluster ? `/cluster/${d.cluster.slug ?? d.cluster.id}` : null;67 const leadTime = cluster?.lead_time_ms ?? d.cluster?.lead_time_ms ?? null;68 const hasFields = (e.field_changes?.length ?? 0) > 0;69 const diffKind = change?.change.kind ?? d.change?.kind ?? null;70 const diffSummary = change?.change.diff ?? d.change?.diff ?? null;71 const defaultTab = diffKind === "text" ? "split" : "semantic";72 const reasons = e.score_reasons ?? [];73 const history = d.history ?? [];74 const jsonLd = {75 "@context": "https://schema.org",76 "@type": "NewsArticle",77 headline: e.title,78 description: e.summary,79 datePublished: e.detected_at,80 dateModified: e.processed_at ?? e.detected_at,81 url: `${SITE_URL}/event/${e.slug}`,82 mainEntityOfPage: `${SITE_URL}/event/${e.slug}`,83 image: [`${SITE_URL}/event/${e.slug}/opengraph-image`],84 author: { "@type": "Organization", name: "WebSensor", url: SITE_URL },85 publisher: { "@type": "Organization", name: "WebSensor", url: SITE_URL },86 about: e.entities.map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/entity/${x.id}` })),87 isBasedOn: e.url,88 keywords: [e.event_type, ...e.categories, ...e.keywords].join(", "),89 };90 const snapRows: [string, SnapshotRow | undefined][] = [91 ["Before", oldSnap],92 ["After", newSnap],93 ];94 return (95 <>96 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />97 <PageHeader98 kicker={99 <span className="flex flex-wrap items-center gap-1.5">100 <Link href={`/source/${e.source?.id ?? e.source_id}`} className="font-mono text-[11px] font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{e.source?.name ?? e.source_id}</Link>101 {e.country && <Flag code={e.country} className="text-[12px]" />}102 <StateBadge state={cluster?.state} />103 {e.silent_change && <Badge kind="silent" />}104 {e.first_party === false ? <Badge kind="external" /> : <Badge kind="first-party" />}105 <EvidenceTag label={e.evidence_label} />106 <TypeChip type={e.event_type} href={`/live?event_type=${e.event_type}`} />107 {e.categories.map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}108 </span>109 }110 title={<span className="[overflow-wrap:anywhere]">{e.title}</span>}111 actions={112 <>113 <ShareButton path={`/event/${e.slug}`} />114 <BookmarkButton eventId={e.id} label />115 {subject && <WatchButton kind="entity" value={subject.id} label={`Watch ${subject.name}`} />}116 <Link href={subject ? `/alerts?entity=${encodeURIComponent(subject.id)}` : "/alerts"} className="inline-flex h-7 items-center rounded-md border border-line bg-panel px-2.5 text-[12px] hover:border-line-strong">Alert</Link>117 </>118 }119 />120 <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]">121 <div className="flex min-w-0 flex-col gap-4">122 {/* WHAT CHANGED */}123 <Panel title={<span>What changed{e.change_class && <span className="ml-2 normal-case tracking-normal text-fg-subtle">· {CLASS_LABELS[e.change_class] ?? e.change_class}</span>}</span>} action={<span className="font-mono text-[11px] text-fg-subtle tabular">{utcDateTime(e.detected_at)}</span>}>124 {hasFields ? (125 <>126 <FieldChanges items={e.field_changes} />127 <p className="mt-3 text-[13px] leading-relaxed text-fg-muted [overflow-wrap:anywhere]">{e.summary}</p>128 </>129 ) : (130 <p className="text-[15px] leading-relaxed [overflow-wrap:anywhere]">{e.summary}</p>131 )}132 </Panel>133134 {/* BEFORE / AFTER */}135 <div id="diff" className="scroll-mt-16">136 <div className="mb-2 flex flex-wrap items-center justify-between gap-2">137 <h2 className="label">Before / after{diffKind ? <span className="ml-2 normal-case tracking-normal text-fg-subtle">· {diffKind} diff</span> : null}</h2>138 <span className="flex flex-wrap gap-3 text-[11px] text-fg-subtle">139 {oldSnap && newSnap && <Link href={`/compare?a=${oldSnap.id}&b=${newSnap.id}`} className="hover:text-fg">open in compare →</Link>}140 <Link href={`/url?u=${encodeURIComponent(e.url)}`} className="hover:text-fg">URL history →</Link>141 </span>142 </div>143 {change || diffSummary ? (144 <DiffViewer unified={change?.unified ?? null} summary={diffSummary} defaultTab={defaultTab} />145 ) : (146 <Panel dense>147 <Empty>No diff is attached to this event{e.event_type === "page_removed" ? " — the page disappeared; its last snapshot is preserved below." : "."}</Empty>148 </Panel>149 )}150 </div>151152 {/* WHY IT MATTERS */}153 <Panel title={<span>Why it matters <span className="normal-case tracking-normal text-fg-subtle">· analysis</span></span>}>154 {e.why_it_matters ? <p className="text-[13.5px] leading-relaxed">{e.why_it_matters}</p> : <p className="text-[13px] text-fg-subtle">No analysis was generated for this event; the observed change speaks for itself.</p>}155 <div className="mt-3 grid gap-3 sm:grid-cols-2">156 <div className="rounded-md border border-line bg-panel-2 p-3">157 <div className="label mb-1 !text-ok">Observed</div>158 <p className="text-[13px]">{observed ?? "Facts are limited to the diff above: the monitored endpoint changed between the two preserved snapshots."}</p>159 </div>160 <div className="rounded-md border border-line bg-panel-2 p-3">161 <div className="label mb-1 !text-info">Inferred</div>162 <p className="text-[13px] text-fg-muted">{inferred ?? "No inference beyond the observed change."}</p>163 <p className="mt-1 font-mono text-[11px] text-fg-subtle">confidence {fmtScore(e.confidence)} · {model}</p>164 </div>165 </div>166 {who && (167 <div className="mt-3">168 <div className="label mb-1">Who it affects</div>169 <p className="text-[13px] text-fg-muted">{who}</p>170 </div>171 )}172 </Panel>173174 {/* EVIDENCE */}175 <Panel title="Evidence" dense>176 <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 px-3 py-2 text-[13px]">177 <span className="label">Monitored URL</span>178 <ExtLink href={e.url} className="min-w-0 break-all font-mono text-[12.5px]">{e.url}</ExtLink>179 </div>180 <Table head={["Snapshot", "Captured", "HTTP", "Size", "Canonical hash", "Raw hash", ""]}>181 {snapRows.map(([label, snap]) => (182 <tr key={label}>183 <Td className="font-medium">{label}</Td>184 <Td mono className="whitespace-nowrap text-fg-subtle">{snap ? utcDateTime(snap.captured_at) : "—"}</Td>185 <Td mono>{snap?.http_status ?? "—"}</Td>186 <Td mono className="whitespace-nowrap">{snap ? fmtBytes(snap.content_length) : "—"}</Td>187 <Td mono className="text-fg-subtle"><span title={snap?.canonical_hash ?? undefined}>{shortHash(snap?.canonical_hash)}</span></Td>188 <Td mono className="text-fg-subtle"><span title={snap?.content_hash ?? undefined}>{shortHash(snap?.content_hash)}</span></Td>189 <Td className="whitespace-nowrap">190 {snap ? (191 snap.has_raw === false ? (192 <span className="text-fg-subtle" title="Raw body pruned by retention; canonical preserved">view raw · pruned</span>193 ) : (194 <a href={`/api/v1/snapshots/${snap.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">view raw</a>195 )196 ) : ""}197 </Td>198 </tr>199 ))}200 </Table>201 {snapRows.some(([, s]) => s && s.has_raw === false) && <p className="px-3 pt-2 text-[11px] text-fg-subtle">Raw body pruned by retention; canonical preserved. Hashes and the canonical diff remain verifiable.</p>}202 <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2 text-[12px]">203 <dt className="text-fg-subtle">Evidence hash</dt>204 <dd className="min-w-0 break-all font-mono text-fg-muted tabular">{newSnap?.canonical_hash ?? "—"}</dd>205 <dt className="text-fg-subtle">Sensor</dt>206 <dd className="min-w-0 truncate"><Link href={`/sensor/${e.sensor_id}`} className="text-info hover:underline">{e.sensor?.name ?? e.sensor_id}</Link> <span className="font-mono text-[11px] text-fg-subtle">{e.sensor?.type}{e.sensor?.connector ? ` · ${e.sensor.connector}` : ""}</span></dd>207 <dt className="text-fg-subtle">Trace</dt>208 <dd className="min-w-0 break-all font-mono text-[11px] text-fg-subtle">event {e.id} → change {e.change_id ?? "—"} → snapshots → sensor {e.sensor_id} → source {e.source_id}{e.processing_version ? ` · ${e.processing_version}` : ""}</dd>209 </dl>210 <div className="flex flex-wrap gap-3 border-t border-line px-3 py-2 text-[11.5px]">211 {oldSnap && newSnap && <Link href={`/compare?a=${oldSnap.id}&b=${newSnap.id}`} className="text-info hover:underline">Compare snapshots</Link>}212 <Link href={`/sensor/${e.sensor_id}`} className="text-info hover:underline">Sensor</Link>213 <Link href={`/url?u=${encodeURIComponent(e.url)}`} className="text-info hover:underline">URL history</Link>214 <Link href={`/source/${e.source?.id ?? e.source_id}`} className="text-info hover:underline">Source</Link>215 </div>216 </Panel>217218 {/* HISTORICAL CONTEXT */}219 <Panel title={<span>Previous changes on this page <span className="font-mono text-fg-subtle">{history.length}</span></span>} dense>220 {history.length ? (221 <ul className="divide-y divide-line">222 {history.map((h) => (223 <li key={h.id} className="grid grid-cols-[5.5rem_1fr_auto] items-start gap-2 px-3 py-1.5 text-[12.5px]">224 <span className="font-mono text-[11px] text-fg-subtle tabular" title={utcDateTime(h.detected_at)}>{relTime(h.detected_at)}</span>225 <div className="min-w-0">226 <div className="flex flex-wrap items-center gap-1.5">227 <Link href={`/event/${h.slug}`} className="min-w-0 truncate hover:underline">{h.title}</Link>228 {h.silent_change && <Badge kind="silent" compact />}229 </div>230 <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-fg-subtle">231 <span>{typeLabel(h.event_type)}</span>232 {h.field_changes && h.field_changes.length > 0 && <FieldChangeInline items={h.field_changes} max={2} />}233 </div>234 </div>235 <Score value={h.importance} size="sm" />236 </li>237 ))}238 </ul>239 ) : (240 <Empty>First change observed on this URL.</Empty>241 )}242 </Panel>243244 {/* RELATED SIGNALS */}245 <Panel title={<span>Related signals <span className="font-mono text-fg-subtle">{d.related.length}</span></span>} dense>246 {d.related.length ? d.related.map((r) => <EventRow key={r.id} ev={r} showDate />) : <Empty>No related signals yet.</Empty>}247 </Panel>248249 {/* CLUSTER / PROPAGATION */}250 {(cluster || d.cluster) && (251 <Panel title="Event cluster · propagation" action={clusterHref && <Link href={clusterHref} className="text-[11px] text-info hover:underline">propagation timeline →</Link>}>252 {d.cluster?.title && <div className="mb-2 text-[13px] font-medium [overflow-wrap:anywhere]">{d.cluster.title}</div>}253 <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">254 <Cell v={cluster?.event_count ?? d.cluster?.event_count ?? 1} l="signals" />255 <Cell v={cluster?.first_party_count ?? d.cluster?.first_party_count ?? 0} l="first-party" />256 <Cell v={cluster?.external_count ?? d.cluster?.external_count ?? 0} l="external" />257 <Cell v={cluster?.source_count ?? d.cluster?.source_count ?? 1} l="sources" />258 </div>259 {leadTime !== null && leadTime > 0 && (260 <p className="mt-3 rounded-md border border-signal/30 bg-signal-soft px-3 py-2 text-[12.5px]">261 <span className="font-semibold text-signal">WebSensor lead time {fmtOffset(leadTime).replace("+", "")}</span> <span className="text-fg-muted">— the first-party change was observed before the first external report.</span>262 </p>263 )}264 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-subtle">265 {(cluster?.state ?? d.cluster?.state) && <span>state <span className="font-mono uppercase text-fg-muted">{cluster?.state ?? d.cluster?.state}</span></span>}266 {(cluster?.velocity ?? d.cluster?.velocity) !== undefined && <span>velocity <span className="font-mono text-fg-muted tabular">{Math.round(cluster?.velocity ?? d.cluster?.velocity ?? 0)}</span></span>}267 {d.cluster && <span>{relTime(d.cluster.first_at)} → {relTime(d.cluster.last_at)}</span>}268 </div>269 {d.cluster && d.cluster.entity_ids.length > 0 && <div className="mt-2 flex flex-wrap gap-1">{d.cluster.entity_ids.slice(0, 8).map((id) => <Chip key={id} href={`/entity/${id}`}>{id.replace(/^(org|prd|ent)_/, "")}</Chip>)}</div>}270 </Panel>271 )}272 </div>273274 {/* RIGHT RAIL */}275 <aside className="flex min-w-0 flex-col gap-4">276 <Panel>277 <div className="flex items-center gap-3">278 <Score value={signal} size="lg" kind="signal" />279 <div className="min-w-0">280 <div className="label">WebSensor signal score</div>281 <div className="text-[12px] text-fg-muted">{signal >= 80 ? "attention now" : signal >= 60 ? "worth a look" : "informational"}</div>282 </div>283 </div>284 <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-3">285 <Gauge label="Importance" value={e.importance} />286 <Gauge label="Confidence" value={e.confidence} tone="info" />287 <Gauge label="Novelty" value={e.novelty} tone="signal" />288 <Gauge label="Impact" value={e.impact_score} tone="high" />289 <Gauge label="Velocity" value={e.velocity_score} tone="silent" />290 <Gauge label="Anomaly" value={e.anomaly_score} tone="mid" />291 </div>292 <div className="mt-4">293 <div className="label mb-1.5">Why this score</div>294 {reasons.length ? (295 <ul className="divide-y divide-line rounded-md border border-line text-[12px]">296 {reasons.map((r, i) => (297 <li key={i} className="flex items-start gap-2 px-2 py-1">298 <span className={`w-2 shrink-0 font-mono font-semibold ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.sign}</span>299 <span className="min-w-0 flex-1 text-fg-muted">{r.text}</span>300 {r.points !== undefined && <span className={`shrink-0 font-mono tabular ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.points > 0 ? "+" : ""}{r.points}</span>}301 </li>302 ))}303 </ul>304 ) : (305 <p className="text-[12px] text-fg-subtle">Scored by heuristics only; no explicit adjustments were recorded for this event.</p>306 )}307 </div>308 {e.change_class && <div className="mt-3 text-[11.5px] text-fg-subtle">Semantic class <span className="font-medium text-fg-muted">{CLASS_LABELS[e.change_class] ?? e.change_class}</span></div>}309 </Panel>310311 <Panel title="Importance components" dense>312 <Table head={["Component", "Weight", "Score"]}>313 {IMPORTANCE_WEIGHTS.map((w) => (314 <tr key={w.key}>315 <Td>{w.label}</Td>316 <Td mono className="text-fg-subtle">{w.weight}%</Td>317 <Td mono>{e.importance_components?.[w.key] !== undefined ? fmtScore(e.importance_components[w.key]) : "—"}</Td>318 </tr>319 ))}320 </Table>321 </Panel>322323 <Panel title="Timing">324 <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">325 <Row label="Published" value={e.published_at ? utcDateTime(e.published_at) : "—"} />326 <Row label="Observed from" value={e.observed_from ? utcDateTime(e.observed_from) : "—"} />327 <Row label="Detected" value={utcDateTime(e.detected_at)} />328 <Row label="Processed" value={e.processed_at ? utcDateTime(e.processed_at) : "—"} />329 <Row label="To feed" value={e.published_to_feed_at ? utcDateTime(e.published_to_feed_at) : "—"} />330 <Row label="Detection latency" value={fmtMs(e.detection_latency_ms)} />331 <Row label="Processing latency" value={fmtMs(e.processing_latency_ms)} />332 {leadTime !== null && leadTime > 0 && <Row label="WebSensor lead time" value={fmtOffset(leadTime).replace("+", "")} tone="text-signal font-semibold" />}333 </dl>334 <p className="mt-2 text-[11px] text-fg-subtle">{relTime(e.detected_at)} · all times UTC</p>335 </Panel>336337 <Panel title="Entities" dense>338 {e.entities.length ? (339 <ul className="divide-y divide-line">340 {e.entities.map((x) => (341 <li key={x.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[13px]">342 <Link href={`/entity/${x.id}`} className="min-w-0 truncate hover:underline">{x.name}</Link>343 <span className="shrink-0 font-mono text-[11px] text-fg-subtle">{x.type}{x.role === "subject" ? " · subject" : ""}</span>344 </li>345 ))}346 </ul>347 ) : (348 <Empty>No entity resolved.</Empty>349 )}350 </Panel>351352 <Panel title="Source reliability">353 <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">354 <dt className="text-fg-subtle">Sensor health</dt><dd className="text-right"><HealthPill health={d.sensor_reliability?.health} /></dd>355 <Row label="Connector success" value={fmtPct(d.sensor_reliability?.success_rate ?? null)} />356 <Row label="Latency" value={fmtMs(d.sensor_reliability?.avg_latency_ms)} />357 <Row label="Runs" value={d.sensor_reliability?.total_runs ?? "—"} />358 <Row label="Raw / meaningful" value={`${d.sensor_reliability?.raw_changes ?? "—"} / ${d.sensor_reliability?.meaningful_changes ?? "—"}`} />359 </dl>360 <p className="mt-2 text-[11px] text-fg-subtle">Sensor type {e.sensor?.type} via {e.sensor?.connector ?? "—"} connector · tier {e.sensor?.tier ?? e.source?.tier ?? "—"}{e.first_party === false ? " · third-party report" : " · first-party channel"}</p>361 </Panel>362363 <Panel title="Interpretation versions" dense>364 {d.interpretations.length ? (365 <ul className="divide-y divide-line">366 {d.interpretations.map((v) => (367 <li key={v.version} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]">368 <span>v{v.version} · <span className="font-mono">{v.model}</span></span>369 <span className="font-mono text-[11px] text-fg-subtle">{utcDateTime(v.created_at)}</span>370 </li>371 ))}372 </ul>373 ) : (374 <p className="px-3 py-2 text-[12px] text-fg-subtle">Heuristic interpretation only ({model}).</p>375 )}376 <p className="border-t border-line px-3 py-2 text-[11px] text-fg-subtle">Raw evidence is immutable; interpretations may be reprocessed and are versioned.</p>377 </Panel>378379 {e.keywords.length > 0 && (380 <Panel title="Keywords">381 <div className="flex flex-wrap gap-1">{e.keywords.map((k) => <Chip key={k} href={`/search?q=${encodeURIComponent(k)}`}>{k}</Chip>)}</div>382 </Panel>383 )}384 <p className="text-[11px] text-fg-subtle">{typeLabel(e.event_type)} · detected by WebSensor · AI-generated analysis never replaces the original evidence above.</p>385 </aside>386 </div>387 </>388 );389}390